
ERP 系統的每一次請求都要先回答兩件事:誰在呼叫、他在哪一家公司。框架若不統一管理,這兩個問題會長進每一段業務邏輯的開頭,變成到處複製的樣板程式碼。
框架的作法是把答案集中在一個物件上:SessionInfo。它看起來只記著「誰登入了」,實際上資料庫路由、客製化、語系、時區、加密、權限這幾條線全部在它身上取值。
本篇說明:
Culture 與 TimeZone 預設值的設計取捨寫第一個 BO 方法時就會碰到的問題:它要先知道「現在是誰」,而取得的方式有三種。
| 作法 | 問題 |
|---|---|
| 靜態的「目前使用者」 | 同時處理多個請求的伺服器上必定互相污染 |
| 每個方法簽章多掛參數 | 每一層都要轉手用不到的東西,漏傳不會有人告訴你 |
| 建構時交到手上 | 框架採用這一種 |
BO 的建構子固定四個參數,宣告在 BusinessObject.cs:
protected BusinessObject(IBeeContext ctx, Guid accessToken, string progId, bool isLocalCall = true)
ctx:一整包已解析的服務(見下)accessToken:這次呼叫的 session 識別progId:呼叫對象的程式識別碼isLocalCall:是否為同行程呼叫所有 BO 共用同一個簽章,是為了讓工廠不必先知道要建的是哪一個家族(註冊表本身是 Day 15 的題目)。
IBeeContext 裝了什麼這一包的內容全宣告在 IBeeContext 上:
public interface IBeeContext
{
IDefineAccess DefineAccess { get; } // 定義存取
ISessionInfoService SessionInfoService { get; } // Session 存取
ILanguageService LanguageService { get; } // 語系文字
IBusinessObjectFactory BoFactory { get; } // BO 之間互相呼叫
IServiceProvider Services { get; } // 逃生門
}
前四個具名,因為幾乎每個 BO 方法都會用到。第五個是逃生門,只給少數方法用,例如登入才需要的那幾個輔助服務。它刻意留成同一個固定寫法,所以誰用了它、用在哪裡,全域搜尋一次就盤得出來。開一個有記號的逃生門,比讓人另外挖一個沒記號的洞好。
這一包裡刻意沒有的東西:
DbConnection
DbTransaction
BO 要碰資料一律走 Repository,由 Repository 自己宣告的資料庫分類,加上 session 解析出的公司,決定連哪一個資料庫(Day 11 已展開)。
還有一個問題,答案同樣不在這一包裡:這一段做完算不算成功。transaction 不是 BO 開的。存檔時由 Repository 開一個 transaction 包住整筆單據,master 先、detail 後,全成功才 commit。BO 覆寫進去的那幾小段跑在 transaction 的哪一邊,是明天整篇的題目。
四個參數裡最不起眼的是 accessToken,它只是一個 Guid。而 BO 需要知道的每一件事,最後都是拿它換來的。
多公司部署時的實際問題:把 companyId 併進 Login 會碰到三個結構性障礙。
框架因此把 session 切成兩段,四個對稱方法:
Login(account, password) ←→ Logout()
EnterCompany(companyId) ←→ LeaveCompany()
兩對是巢狀的,外層管身分,內層管公司:
| 方法 | 做完之後 |
|---|---|
Login |
有身分。能呼叫不綁公司的方法,EnterCompany 就是其中一支 |
EnterCompany |
有公司脈絡。company 那一類要連哪一個資料庫,這時才解得出來 |
LeaveCompany |
公司脈絡清掉,session 還活著 |
Logout |
session 銷毀,內部會先清一次公司脈絡 |
表上看不出來的兩件事:
LeaveCompany,EnterCompany 直接覆寫舊值(兩步驟會產生非原子的中間狀態)Login 是匿名的,任何人都打得到,所以它另外掛著一個計數器。ILoginAttemptTracker 記同一個帳號連續失敗幾次,登入成功就歸零;框架附的預設實作在連續失敗到一定次數之後把那個帳號鎖上一段時間,而宿主可以整支換掉。
鎖多久、鎖帳號還是鎖來源、要不要通知,是各家資安政策決定的:有的要配合公司既有的帳號規定,有的要接通知管道,有的根本不鎖只記錄。而「連續失敗要被數到」各家都一樣。Day 2 那條判別法在認證上就長這個樣子。
不一樣的那一半,框架現在開的是一個可替換的實作,不是一份設定。ERP 的資安管理最後多半會落在設定層,一家公司一組門檻;要走到那裡,得先有夠多部署把同一組參數提出來。
SessionInfo 裝了什麼兩階段不只是流程規約,它就是 SessionInfo 這個型別的形狀:公開屬性依「由哪一段填入」分成兩組。
Login 填入(七個)
| 屬性 | 內容 | 消費者 |
|---|---|---|
AccessToken |
session 識別(Guid) |
每次身分驗證 |
ExpiredAt |
到期時間(UTC) | 每次身分驗證 |
UserId |
帳號 | 稽核(Day 25) |
UserName |
顯示名稱 | 稽核(Day 25) |
Culture |
BCP-47 語言代碼 | 多語系(Day 20) |
TimeZone |
IANA 時區代碼 | 時區換算(Day 28) |
ApiEncryptionKey |
本連線的對稱金鑰 | Payload 加密(Day 23) |
EnterCompany 填入(六個)
| 屬性 | 內容 | 消費者 |
|---|---|---|
CompanyId |
目前公司 | 資料庫路由(Day 11) |
CustomizeId |
該公司套用的客製化代碼 | 客製化(Day 19) |
Roles |
使用者在該公司的角色 | 權限(Day 24) |
UserRowId |
st_user.sys_rowid |
權限(Day 24) |
EmployeeRowId |
st_employee.sys_rowid |
權限(Day 24) |
DeptRowId |
st_employee.dept_rowid |
權限(Day 24) |
CompanyId 是其中唯一可為 null 的,語意明確:已登入、尚未進公司。Day 11 講過 Repository 在建構當下就解析路由,而這個狀態正是解不出來的情況之一,會被當場擋掉。
公司代號還會換到一份 CompanyInfo,這一份在快取裡,來源是一句查詢,第一次用到才讀。它除了回答公司資料庫位址,還帶著公司層的覆寫:數字位數、公司本幣、現金最小收付單位、可用幣別清單。進公司備好的不只是一個位址,數值語意本身是 Day 26 的題目。
這六個值由同一支私有方法一次清掉,它與本篇後面幾個片段都出自 SystemBusinessObject.Session.cs:
private static void ClearCompanyContext(SessionInfo sessionInfo)
{
sessionInfo.CompanyId = null;
sessionInfo.CustomizeId = string.Empty;
sessionInfo.Roles = [];
sessionInfo.UserRowId = Guid.Empty;
sessionInfo.EmployeeRowId = Guid.Empty;
sessionInfo.DeptRowId = Guid.Empty;
}
LeaveCompany 與 Logout 走的都是這一支。
伺服器重啟或負載平衡把下一個請求送到另一台機器,手上的 token 還算不算數?
框架兩份都留,但寫進 st_session 的只有 session 自己的身分與到期時間,加上重建時湊不回來的那一個值。這一份稱為 seed,型別是 SessionUser:
new SessionUser
{
AccessToken = sessionInfo.AccessToken,
UserID = sessionInfo.UserId,
UserName = sessionInfo.UserName,
EndTime = sessionInfo.ExpiredAt,
CompanyId = sessionInfo.CompanyId,
}
seed 只放五個值,其餘的都能重新產生:
| 值 | 重建方式 |
|---|---|
Culture / TimeZone |
重讀 st_user |
ApiEncryptionKey |
由 token 重新推導 |
CustomizeId / Roles / 三個 RowId |
重跑 EnterCompany 的解析 |
只有「使用者選了哪一家公司」在資料庫裡沒有其他地方記得,所以必須存。
快取沒命中時的重建有三個性質:
EnterCompany 與重建共用同一段程式(SessionCompanyBinder)。兩條路必須落在相同狀態,否則權限會取決於請求打到哪一台機器。CreateSession 拿一個帳號直接發一張 token,完全不驗憑證,與 Login 的差別只有這一件;後面推導金鑰、寫 seed、放快取那一整段共用同一個方法。
用途是排程、匯入、對帳這類沒有人坐在前面打密碼、卻仍然需要一個身分才跑得動的作業。沒有它,那些作業只剩兩條路:在設定檔裡放一組服務帳號的密碼,或乾脆繞過整套權限自己開連線。
代價是這支方法本身就是一把萬能鑰匙,所以它在存取控制上宣告成只接受同行程呼叫:走 HTTP 進來的請求在進到方法之前就被擋掉,能呼叫它的只有跟後端跑在同一個行程裡的程式。第一節建構子那個 isLocalCall 收的是同一個值,不經過那道驗證的呼叫路徑,就靠它擋。
Culture 與 TimeZone 的預設值這兩個屬性的預設值都是空字串,而且是刻意的。
FrameworkClock / DateTimeZoneConverter / PayloadZoneConverter 遇到空時區 → 一律當 UTC空字串的語意是「走預設路徑」,不是「還沒填」。
真正填值的是登入那一段(repo 是使用者資料表的 Repository):
var locale = repo.GetLocale(sessionInfo.UserId);
var backend = DefineAccess.GetSystemSettings().BackendConfiguration;
sessionInfo.TimeZone = StringUtilities.IsNotEmpty(locale.TimeZone)
? locale.TimeZone : backend.DefaultTimeZone;
sessionInfo.Culture = StringUtilities.IsNotEmpty(locale.Culture)
? locale.Culture : backend.DefaultLanguage;
這兩個值往回退有三層:st_user 那一列 → BackendConfiguration → 各消費端自己的預設。
BackendConfiguration 這兩個設定的預設值是 zh-TW 與 Asia/Taipei,所以什麼都沒調的部署拿到的就是它們。這個決定放在設定裡,不在原始碼裡:填空字串即可讓整個部署走各消費端自己的預設。未登入的呼叫沒有 session,走的一直是最後那一層。
案例只有一家公司,登入之後照樣走完進公司那一步。
st_user(雜湊比對、顯示名稱同列讀出),進公司走框架自己的 EnterCompany。應用只 seed 兩列資料,寫在 NorthwindSchemaSeeder.cs:st_company 一列,客製化代碼就填在它的 customize_id;st_user_company 一列,少了它 EnterCompany 一律當成無存取權st_user 填了 Asia/Taipei 與 en-US,兩欄都走「使用者自有值」那條路;語言那一欄與部署預設的 zh-TW 不同,所以分得出來走的是哪一條;CompanyId 一路走到路由,把所有歸在 company 那一類的表單接到同一個資料庫一家公司也要走兩段,看起來是形式。真正省不掉的是進公司那一步寫進 seed 的公司代號,session 上其他的值都能重算,只有這一個不行。
一個 session 要管的值,分兩段到位:
Login 答完「你是誰」,同時備好語言、時區與加密金鑰EnterCompany 答完「在哪一家公司」,同時把跟著公司走的那幾個一起填上這條界線不是規約,是相依關係算出來的:第二組的每一個值都得先知道公司才推導得出來。先把值與值之間的相依排清楚,生命週期通常就已經被決定了。
可以帶走的判準:框架要求應用「只寫業務判斷」的時候,它欠應用一份清單,寫明哪些問題已經被答完。清單缺一項,那一項就會以某種形式長回每一段業務邏輯的開頭:一個靜態的目前使用者、一個到處轉手的參數、或一個每次都要重查的查詢。
明天談框架把存檔切成哪幾個擴充點,以及什麼時候該接手其中一個。
本系列同步發表於 HackMD,完整目錄